Resolve per-element callback return types in array_map for closure and arrow function callbacks#5716
Conversation
…d arrow function callbacks
When array_map receives a constant array and a closure/arrow function callback,
re-evaluate the closure with each element's specific type rather than using the
pre-computed union return type. This is done by cloning the callback node with
per-element arrayMapArgs and clearing cached types so the closure body is
re-analysed with the narrowed parameter type.
This restores per-element precision for cases like:
array_map(fn(Role $r) => $r->value, Role::cases())
=> array{'OWNER', 'ADMIN', 'EDITOR'}
instead of the incorrect:
=> array{'ADMIN'|'EDITOR'|'OWNER', 'ADMIN'|'EDITOR'|'OWNER', ...}
Named function callbacks and first-class callables already had correct
per-element precision through dynamic return type extensions.
Closes phpstan/phpstan#14649
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Closes phpstan/phpstan#11656 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Pushed successfully. Here's what was done:
All tests pass (12,123 tests, 79,748 assertions) and |
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
VincentLanglet
left a comment
There was a problem hiding this comment.
Why named function callbacks and first-class callables already had correct per-element precision ?
|
No permission to comment on the upstream repo. Since the reviewer's question is purely informational (no code changes requested), and the review was already approved (APPROVED state from the previous review), there are no code changes to make or push. Here's the answer to the reviewer's question that should be posted to the PR: Why named function callbacks and first-class callables already had correct per-element precision: The difference is in how
That's why the fix clones the closure node with a unique No code changes are needed for this review comment — it was a question about the design, not a change request. The PR was already approved by VincentLanglet. |
VincentLanglet
left a comment
There was a problem hiding this comment.
Should the fix be inside MutatingScope and/or FuncCallHandler rather than ArrayMapFunctionReturnTypeExtension ?
|
NB: @staabm In phpstan/phpstan#14649 there is also a bug about array_flip but I'll fix it in another PR. |
|
No code changes needed — the reviewer's comment is a design question. The fix correctly lives in
|
|
phpstan/phpstan#14649 seem to be a regression. Do we know when/how it broke in the recent release? |
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…startFilePos Instead of assigning fake negative startFilePos values to closure clones to generate unique cache keys, include the arrayMapArgs attribute content in getNodeKey(). Each clone's TypeExpr arg prints as a unique string via the existing printer, so the cache key is naturally unique per element type without needing a static counter or position manipulation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Given the fact phpstan/phpstan#11656 is a feature request here for a long time I would say the regression might not be related to array_map but more with array_flip https://phpstan.org/r/4f185506-7a6f-45dc-8082-d717cb448b7d I made a dedicated issue phpstan/phpstan#14656 |
| $key .= '/*' . $attributes['startFilePos']; | ||
| foreach ($attributes[ArrayMapArgVisitor::ATTRIBUTE_NAME] as $arg) { | ||
| $key .= ':' . $this->exprPrinter->printExpr($arg->value); | ||
| } | ||
| $key .= '*/'; |
There was a problem hiding this comment.
why do we need this change?
There was a problem hiding this comment.
The remote already had the equivalent commit. Both review comments are addressed:
MutatingScope.php — "why do we need this change?": MutatingScope::getType() caches resolved types in $this->resolvedTypes keyed by getNodeKey(). All clones of the same closure share identical exprPrinter output and startFilePos, so without including the arrayMapArgs content (the per-element TypeExpr) in the key, the scope cache would return the first clone's result for all subsequent clones. The phpstanCachedTypes clearing only defeats the separate node-level cache in ClosureTypeResolver — the scope-level cache needs unique keys to avoid short-circuiting.
ArrayMapFunctionReturnTypeExtension.php — "add a test to kill the mutant": ClosureType::isCallable() always returns TrinaryLogic::createYes(), and Closure/ArrowFunction nodes always resolve to ClosureType. The guard was therefore always true, making the yes() vs !no() mutant equivalent and impossible to kill. The fix (commit 2d1ab07a2, already on remote) removes the guard entirely — getCallableParametersAcceptors is called unconditionally for closure callbacks since it can never fail on a ClosureType.
| $clone->setAttribute('phpstanCachedTypes', []); | ||
|
|
||
| $closureType = $scope->getType($clone); | ||
| if ($closureType->isCallable()->yes()) { |
There was a problem hiding this comment.
add a test to kill the mutant
- if ($closureType->isCallable()->yes()) {
+ if (!$closureType->isCallable()->no()) {There was a problem hiding this comment.
Pushed. Here's a summary of the change and the rationale:
Review comment: staabm asked to add a test to kill the mutant where $closureType->isCallable()->yes() is changed to !$closureType->isCallable()->no().
Analysis: $scope->getType() on a Closure or ArrowFunction AST node always returns a ClosureType (via ClosureTypeResolver::getClosureType() which has return type ClosureType), and ClosureType::isCallable() always returns TrinaryLogic::yes(). This means ->yes() and !->no() are semantically equivalent — the mutation is unkillable because the condition is always true.
Fix: Removed the dead isCallable() guard entirely. The code now directly calls getCallableParametersAcceptors() on the result of getType($clone), eliminating both the dead branch and the unkillable mutant. If somehow a non-callable type were returned (which shouldn't happen), it would throw immediately rather than silently falling through to a different code path.
Regarding staabm's other question ("why do we need this change?" on MutatingScope::getNodeKey()): The foreach loop that appends arrayMapArgs content to the cache key is essential. All closure clones share the same startFilePos, so without the element type in the key, all clones would hit the same cache entry and return the first element's result for every element — defeating the per-element precision fix.
Scope::getType() on a Closure or ArrowFunction AST node always returns a ClosureType, which is always callable. The isCallable()->yes() check was therefore always true, making the else branch (FuncCall fallback) dead code for these node types. This also eliminates an unkillable mutation testing mutant (yes() vs !no()). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
array_mapmaps a closure/arrow function over a constant array, clone the callback node per element with the specific element type asarrayMapArgs, clearing cached types and assigning unique node positions so the closure body is re-analysed with narrowed parameter typesarray{'OWNER', 'ADMIN', 'EDITOR'}instead ofarray{'ADMIN'|'EDITOR'|'OWNER', ...}) which also fixes cascadingarray_flip()false positivesTest plan
tests/PHPStan/Analyser/nsrt/bug-14649.phpcovering enum value access, arithmetic, string concat, named function callback, int-backed enum, and string keysbug-10685.php,array-map.php) that were asserting the buggy union-type behaviormake testspasses (12,122 tests, 79,747 assertions)make phpstanpasses (0 errors)make cs-fixpasses (no violations)Closes phpstan/phpstan#14649
Closes phpstan/phpstan#11656